iT邦幫忙

2026 iThome 鐵人賽

DAY 30
0
Modern Web

現代函式庫與JavaScript的關係系列 第 30 篇

Day 30 | 30 天的地圖 —— 五條線,一個共同的根

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20261001/20183570fhg6nov67y.png

這篇要給你的四樣東西

  1. 一張按「線」排的地圖,不是按日期排的目錄。為什麼不按日期,第一節會說
  2. 一個 20 分鐘的講述順序,被問到「你這 30 天學到什麼」的時候可以直接用
  3. 三個聽起來很合理、但拿去量就不成立的結論 —— 這三個是整個系列裡最值得單獨帶走的
  4. 一支可以重跑的自我檢查:node day30-series-self-check.js,11 個檢查、42 個斷言,一次跑完

這篇沒有新的機制可挖。它要做的是把前面 29 篇變成一個可以用的東西。


一、為什麼地圖按「線」排,不按日期排

如果按日期排,這篇就是一份 30 列的目錄 —— 而目錄的問題是:它只在你已經讀過的時候有用。

更實際的理由是:在面試裡沒有人會讓你按日期念 30 個標題。對方問的是「你這 30 天在幹嘛」,而這個問題要的是一個結構。

所以我把 30 篇重排成五條線。每一條線收在一個問句上,而那個問句可以直接套到你沒讀過的題目上。

五條線,以及它們共同的根

先講根,因為它是整個系列的起點:

React 沒有攔截層,所以它只能比參考。

Vue 用 Proxy 攔截屬性讀寫,Angular 用 Zone.js 或 Signal 做收集。React 在「攔截、依賴收集、觸發更新」這三個零件上一個都不做(Day 23)。

於是它只剩一種判斷方式:拿新舊兩個值用 Object.is 比參考。 下面五條線,全部都是這一個決定造成的後果。

線 涵蓋 這條線要回答的那一句
一 值與記憶體 引擎管線、Stack/Heap 與 GC、傳值傳址、相等性四種演算法、型別轉換、作用域與閉包、資料型別、原型鏈與屬性列舉 「兩個東西算不算同一個?」
二 讀原始碼 靜態方法與實例方法(Day 3)、setState 的 12 行(Day 24)、reconcileChildrenArray(Day 27)、props 從規格到 rdi 到 Fiber(Day 28) 「它實際上寫了什麼?」
三 框架的設計選擇 useMemo 三框架對照(Day 16)、呼叫 API 的寫法差異(Day 22)、攔截/收集/觸發三零件(Day 23) 「它為什麼選了這一條?」
四 遠端資料 hooks 無限迴圈(Day 19)、競態條件(Day 20)、樂觀更新與回滾(Day 21)、queryKey(Day 25)、structural sharing 與 useMemo(Day 26) 「我手上這份資料是最新的嗎?」
五 上線 HTML parsing 與打包(Day 8)、框架資安差異(Day 17)、三大編譯陷阱(Day 18)、環境變數與 Docker 與 CI/CD(Day 29) 「這件事發生在哪一個時間點?」

那五個問句才是真正的產出。 答案會過期 —— React 會改版、TanStack Query 會改預設值、Node 會變快。但那五個問句不會過期,而且你拿到一個沒讀過的題目時,它們還是能用。


二、20 分鐘怎麼講:四段

被問到「你這 30 天學到什麼」的時候,不要從 Day 1 開始念。按這個順序:

第一段(約 2 分鐘):一句話的主張

「React 沒有攔截層,所以它只能比參考。我這 30 天就是在追這個決定造成的每一個後果。」

白話解釋這句話為什麼有效:它同時交代了你知道 React 的設計選擇、你知道別家不一樣、以及你有一條貫穿的線。三件事一句話講完。

第二段(約 6 分鐘):挑一條線,從上往下走完

挑一條,不要每條都講三句。 對方要看的是深度,不是覆蓋率。

我會挑線二或線四:

  • 線二適合技術深度的場合:從「hasOwnProperty 是實例方法」講到「React 為什麼寫 .call」,再講到「而它自己在同一個檔案裡並不總是這樣寫」。這條路從語言規格一路走到真實原始碼,而且每一步都有檔案可以指
  • 線四適合講實戰的場合:從「競態條件」講到「樂觀更新」再講到「參考同一性」,因為這三個是我專案裡真的出過的狀況

第三段(約 8 分鐘):講一個「直覺是錯的」的結論,並說你怎麼知道

這一段是最能拉開差距的,因為它證明的不是「我讀過」,是「我驗證過」。

第三節有三個現成的可以用。

第四段(約 4 分鐘):留一個可驗證的東西

node day30-series-self-check.js

白話解釋這行:這支檔案把 30 天的頭條結論各自寫成一個會自己判斷對錯的斷言,跑一次就知道 11 個檢查、42 個斷言全不全通過。

為什麼這一段有用:它把「我說的」變成「你可以跑的」。 這是履歷上任何形容詞都換不到的東西。


三、三個拿去量就不成立的直覺

這三個是整個系列裡我覺得最值得單獨帶走的,因為它們剛好都屬於同一類:聽起來非常合理,而且網路上很多文章就是這樣寫的,但拿去量就不成立。

其一:「樂觀更新讓介面快十倍」→ 倍數不是這件事的單位

實測(Day 21):悲觀更新按下按鈕後 602ms 畫面才有反應,樂觀更新是 0.01ms 等級。

倍數算得出來 —— 大約一萬倍 —— 而那個數字大到荒謬,剛好證明倍數是錯的單位。 分母是「同步執行一行程式碼」,分子是「等一個網路來回」。把伺服器改成 3 秒,倍數就變五倍大,但使用者的感受不會跟著變五倍好。

正確的尺是 Jakob Nielsen 1993 年整理的三個反應時間界線:0.1 秒以內覺得即時、1 秒以內思緒不中斷、10 秒以內注意力還在。

樂觀更新買到的是「跨過 100 毫秒那條線」,不是一個倍數。

而這個講法會改變你的判斷:如果你的 API 本來就只花 60ms,它已經在第一區間了,加上樂觀更新使用者感受不到差別 —— 你只是多養了一整套回滾邏輯。

其二:「index 當 key 會有效能問題」→ 代價是正確性,不是效能

實測(Day 27):五筆列表刪掉第一筆,index key 新建的 DOM 數量是 0 個,id key 也是 0 個。

index key 並沒有「把節點全部重建」。它重用了全部節點 —— 問題是它重用到錯的資料上:

用 index 當 key,刪掉第一筆之後:
  dom#75  夏季展    input=「」
  dom#76  秋季展    input=「要訂便當」   ← 使用者打的字留在錯的那一行

錯的那一刻在 createWorkInProgress 的這一行:

workInProgress.stateNode = current.stateNode

白話解釋這行:fiber 被重用的時候,它身上那個 DOM 節點被原封不動地沿用。而使用者打在 DOM 上的字、scroll 位置、<video> 播到第幾秒、子元件自己的 useState,全部黏在那個 DOM 節點與 fiber 上 —— 不在 props 裡,所以 React 不知道它們需要跟著搬。

key 不是效能設定,是身分證。給錯身分證,使用者的東西就會住到別人家。

其三:「process.env 會自動去除引號」→ 去引號的是讀檔案的那個人

這句話我自己一年前查到過,而且當時覺得很合理(Day 29)。

process.env 只是一個物件,它不解析任何東西。真正去引號的是讀那個 .env 檔的人,而每個人的規矩不一樣。實測同一個八行的 .env 檔:

誰在讀 成對引號會怎樣
dotenv / Next.js / Vite 去掉
docker compose 去掉
docker run --env-file 留著,變成值的一部分
Zeabur / Vercel 面板的輸入框 你貼什麼就是什麼

八行裡有六行在兩種解析器下結果不同。而這就是那個經典的「本機正常、上線就壞」:.env.development 寫了引號,本機 dotenv 幫你去掉;同一串貼到面板,引號留著,送出去的金鑰前後多了兩個 "。


四、哪幾篇可以互相當對照組

這是讀這個系列的第二種方式:不是往下讀,是左右比。

對照組 為什麼放在一起看才看得懂
Day 23(三零件)× Day 26(structural sharing) Day 23 說 React 沒有攔截層所以只能比參考;Day 26 是「所以函式庫怎麼幫你把參考穩住」。前者是限制,後者是補償
Day 21(樂觀更新)× Day 26(要不要重繪) 一個是「盡量造新物件」(回滾靠 { ...cache, ...patch }),一個是「盡量不造新物件」(replaceEqualDeep 內容沒變就回傳原本那個)。兩篇合起來才是完整的「什麼時候該給新參考」
Day 24(setState 的 12 行)× Day 27(reconcileChildrenArray) 方法完全一樣:讀原始碼那幾行 → 手寫最小版驗證。而且兩篇都遇到同一件事 —— 官方在註解裡老實寫了理由(Avoid an extra prototype jump 與 double buffering pooling technique)
Day 20(競態)× Day 21(回滾)× Day 24(setState 不立刻更新) 三個看起來不同的 bug,是同一個結構問題:你手上那份資料,在你讀它的時候可能已經不是最新的了
Day 16(useMemo 怎麼用)× Day 26(上游決定它有沒有用) Day 16 的視角是 API 本身,Day 26 的視角是上游。同一行 useMemo,上游參考穩不穩決定它是最佳化還是純成本
Day 27(本機測不到的錯位)× Day 29(本機測不到的部署錯) 兩篇的 bug 都有同一個特徵:在本機永遠重現不了。一個要 30ms 內做兩個操作,一個要換一個環境。這類 bug 的對策都是理解機制,不是多測幾次

五、那支自我檢查怎麼用

day30-series-self-check.js 把 11 條頭條結論各自寫成一組斷言。跑起來長這樣:

| 對應的那一篇 | 頭條結論 | 斷言 | 結果 |
|---|---|---|---|
| Day 4|相等性四種演算法 | Object.is 比的是參考,所以「原地改物件」在 React 眼裡等於沒變 | 3/3 | 通過 |
| Day 24|setState 只有 12 行 | setState 自己不改 state、不重繪、不排程 | 4/4 | 通過 |
| Day 27|key 不是效能設定是身分證 | 不給 key 等於 key 用 index,刪掉第一筆會讓字留在錯的那一行 | 2/2 | 通過 |
| Day 26|快取回來後要不要重繪 | 內容沒變就回傳原本那個物件,但一個 Date 欄位就整包破功 | 5/5 | 通過 |
...
  檢查:11 / 11 通過
  斷言:42 / 42 通過

它的寫法是這樣(節錄):

檢查(
  'Day 27|key 不是效能設定是身分證',
  '不給 key 等於 key 用 index,刪掉第一筆會讓使用者打的字留在錯的那一行',
  () => ({
    斷言: [
      ['用 index 當 key,那串字跑到了', 跑(false), '秋季展'],
      ['用 id 當 key,那串字還在', 跑(true), '夏季展'],
    ],
    附註: '兩種 key 新建的 DOM 數量都是 0 —— 問題不是效能,是配給了錯的資料',
  }),
)

白話解釋這段:每個檢查交出一張「標籤、實際值、期望值」的三欄清單,框架用 Object.is 逐項比對。不通過的那一項會被標上 ✗ 並印出期望值,所以你一眼看得出差在哪。

它真正的用途不是今天跑一次就好,是哪一天它開始不通過。

那表示 Node、React 或 TanStack Query 改了行為 —— 而那一刻你會知道是哪一條結論過期了,不用重讀 30 篇文章。這也是為什麼它刻意寫成零依賴:三年後它還是跑得起來。


六、如果你也想做一個這樣的系列:三個實際建議

這一節是給想做同類系列的人的,不是心得。三個我會提前告訴自己的事:

a. 先決定「一條線」,再決定 30 個題目

我最初的大綱是 30 個主題的清單,而清單寫出來的東西會很像教科書目錄。真正讓系列成立的是那條貫穿的線(這篇講的「只能比參考」),而它應該在 Day 1 之前就想清楚。

判斷方法很簡單:如果抽掉中間任何一天,後面的文章還讀得通,那條線就不夠緊。

b. 每一篇配一支可執行的 demo,而且零依賴優先

這 30 天我寫了 29 支 .js,絕大多數不需要 npm install。好處有三個,而且第三個最重要:

  • 第一,寫 demo 的過程會逼你發現你其實沒搞懂的地方
  • 第二,讀者可以驗證,文章就不只是「我覺得」
  • 第三,它會擋住你寫出漂亮但錯的句子 —— 因為你要先讓它跑出那個數字

c. 把「出處分級」當成固定格式,而不是補充

我每一篇最後都有一節「這篇的每個說法各自從哪來」,分成五層:官方出處/我實測/我的判斷/我沒驗證的部分/量測限制。

第四層是最有價值的那一層。寫技術文章最大的風險不是寫錯,是把推論寫成事實。固定留一個欄位給「這部分我沒驗證」,會讓你在寫的時候就被迫分清楚兩者 —— 而讀者拿到的東西也因此可以被信任。


完整 demo 原始碼

檔名 day30-series-self-check.js,沒有任何依賴:

node day30-series-self-check.js

全部通過時結束碼是 0,有任何一項不通過是 1 —— 所以它也可以直接丟進 CI 當迴歸測試。我的環境是 Node.js v22.22.2。

/**
 * day30-series-self-check.js
 *
 * 把這 30 天的頭條結論,一個指令全部重跑一次
 * 搭配 iThome 鐵人賽 2026 Day 30
 *
 * 執行方式:node day30-series-self-check.js
 * 環境:Node.js v18 以上。**沒有任何依賴**
 *
 * 這支檔案跟前面 29 天的 demo 不一樣:
 * 它不解釋機制(那是前面每一篇的事),它只做一件事 ——
 * **把每一篇的頭條結論寫成一個會自己判斷對錯的斷言,然後印出通過或失敗。**
 *
 * 為什麼要有這支檔案:
 *   整個系列最怕的不是寫錯,是「寫得很有道理但沒人驗證得了」。
 *   所以收尾留下的不是心得,是一支可以重跑的檢查。
 *   它真正的用途是「哪一天它開始不通過」—— 那表示某一條結論過期了,
 *   而你會知道是哪一條,不用重讀 30 篇文章。
 */

'use strict'

// ============================================================
// 檢查框架
// ============================================================

const 檢查們 = []

/**
 * 註冊一個檢查
 * @param {string} 哪一天 對應的那一篇
 * @param {string} 結論    那一篇的頭條結論,用一句話寫
 * @param {Function} 跑    回傳 { 斷言: [[標籤, 實際值, 期望值], ...], 附註?: string }
 */
function 檢查(哪一天, 結論, 跑) {
  檢查們.push({ 哪一天, 結論, 跑 })
}

function 分隔線(title) {
  console.log('\n' + '='.repeat(74))
  console.log(title)
  console.log('='.repeat(74))
}

/** 把值印成人看得懂的形式 */
const 印值 = (v) => {
  if (v === '') return "''(空字串)"
  return typeof v === 'string' ? v : JSON.stringify(v)
}

// ============================================================
// 線一:值與記憶體 —— 「React 只能比參考」
// ============================================================

檢查(
  'Day 4|相等性四種演算法',
  'Object.is 比的是參考,所以「原地改物件」在 React 眼裡等於沒變',
  () => {
    const 原本 = { sold: 120 }

    // 白話解釋:原地改是同一個物件,所以參考不變
    const 原地改 = 原本
    原地改.sold = 45

    // 白話解釋:展開運算子會產生一個新物件,所以參考一定不同
    const 給新物件 = { ...原本, sold: 45 }

    // 白話解釋:淺複製只換外層,但外層換了參考就已經不同了
    const 淺複製 = { ...原本 }

    return {
      斷言: [
        ['原地改之後還是同一個參考', Object.is(原本, 原地改), true],
        ['給新物件是不同參考', Object.is(原本, 給新物件), false],
        ['淺複製是不同參考', Object.is(原本, 淺複製), false],
      ],
      附註: '所以 React 要你給新物件不是風格要求,是它只有這一種判斷方式',
    }
  },
)

檢查(
  'Day 9|物件的真面目',
  'in 看整條原型鏈、hasOwnProperty 只看自己、Object.keys 還多要求可列舉',
  () => {
    const 父 = { 繼承來的: 1 }
    const 子 = Object.create(父)
    子.自己的 = 2
    // 白話解釋:用 defineProperty 定義而不給 enumerable,預設就是不可列舉
    Object.defineProperty(子, '不可列舉的', { value: 3, enumerable: false })

    const has = Object.prototype.hasOwnProperty

    return {
      斷言: [
        ['繼承來的:in', '繼承來的' in 子, true],
        ['繼承來的:hasOwnProperty', has.call(子, '繼承來的'), false],
        ['繼承來的:Object.keys', Object.keys(子).includes('繼承來的'), false],
        ['不可列舉的:in', '不可列舉的' in 子, true],
        ['不可列舉的:hasOwnProperty', has.call(子, '不可列舉的'), true],
        ['不可列舉的:Object.keys', Object.keys(子).includes('不可列舉的'), false],
      ],
      附註: '三個分界:要不要走原型鏈、可不可列舉、存不存在',
    }
  },
)

// ============================================================
// 線二:讀原始碼 —— 「借方法來問,不要問物件自己」
// ============================================================

檢查(
  'Day 24|setState 只有 12 行',
  'config 是外部資料,所以 obj.hasOwnProperty(k) 會被地雷炸掉,React 才寫 .call',
  () => {
    const has = Object.prototype.hasOwnProperty

    // 白話解釋:天真版問物件自己有沒有這個 key,前提是那個物件身上真的有這個方法
    const 天真 = (config) => {
      const out = {}
      for (const k in config) if (config.hasOwnProperty(k)) out[k] = config[k]
      return out
    }
    // 白話解釋:React 版從 Object.prototype 把方法借出來,用 .call 指定「要問誰」
    const 借用 = (config) => {
      const out = {}
      for (const k in config) if (has.call(config, k)) out[k] = config[k]
      return out
    }

    const 地雷一 = { hasOwnProperty: 'oops', title: 'hi' }      // 名字被 prop 佔走了
    const 地雷二 = Object.create(null); 地雷二.title = 'hi'       // 根本沒有原型可以繼承

    /** 跑一次,回傳 'ok' 或錯誤的型別名稱 */
    const 試 = (fn, config) => {
      try { fn(config); return 'ok' } catch (e) { return e.constructor.name }
    }

    return {
      斷言: [
        ['天真版遇到 prop 名叫 hasOwnProperty', 試(天真, 地雷一), 'TypeError'],
        ['天真版遇到 Object.create(null)', 試(天真, 地雷二), 'TypeError'],
        ['借用版遇到 prop 名叫 hasOwnProperty', 試(借用, 地雷一), 'ok'],
        ['借用版遇到 Object.create(null)', 試(借用, 地雷二), 'ok'],
      ],
      附註: '規則不是「一律加 .call」,是「物件在不在自己控制範圍內」',
    }
  },
)

檢查(
  'Day 24|setState 只有 12 行',
  'setState 自己不改 state、不重繪、不排程,它只把事情丟給 this.updater',
  () => {
    // 照抄 React v19.3.0 ReactBaseClasses.js 的那 12 行(註解省略)
    function setState照抄版(partialState, callback) {
      if (
        typeof partialState !== 'object' &&
        typeof partialState !== 'function' &&
        partialState != null
      ) {
        throw new Error('takes an object of state variables to update or a '
          + 'function which returns an object of state variables.')
      }
      this.updater.enqueueSetState(this, partialState, callback, 'setState')
    }

    const 收到的 = []
    const 元件 = {
      state: { count: 0 },
      // 白話解釋:這個假的 updater 只負責把收到的 patch 記下來,什麼事都不做
      updater: { enqueueSetState(inst, patch) { 收到的.push(patch) } },
      setState: setState照抄版,
    }

    元件.setState({ count: 1 })

    const 丟錯 = (參數) => {
      try { 元件.setState(參數); return false } catch (e) { return true }
    }

    return {
      斷言: [
        ['呼叫之後 this.state 完全沒變', 元件.state.count, 0],
        ['updater 收到了那筆 patch', 收到的.length, 1],
        ['setState(123) 會丟錯', 丟錯(123), true],
        ['setState(null) 不會丟錯', 丟錯(null), false],
      ],
      附註: 'null 被放過是因為條件寫的是 partialState != null(寬鬆比較)',
    }
  },
)

// ============================================================
// 線三:列表與身分 —— 「key 是身分證,不是效能設定」
// ============================================================

檢查(
  'Day 27|key 不是效能設定是身分證',
  '不給 key 等於 key 用 index,刪掉第一筆會讓使用者打的字留在錯的那一行',
  () => {
    /** 假的 DOM 節點。value 模擬使用者打進去的字 —— 這個值 React 不知道它的存在 */
    const 造節點 = () => ({ value: '' })

    /**
     * 跑一次「使用者在第二列打字 → 刪掉第一筆」
     * 回傳「那串字最後出現在哪一列」
     */
    function 跑(用id當key) {
      const 資料 = [
        { id: 'a', 名稱: '春季展' }, { id: 'b', 名稱: '夏季展' },
        { id: 'c', 名稱: '秋季展' }, { id: 'd', 名稱: '冬季展' },
      ]
      // 首次掛載:每一列配一個 DOM 節點
      const 舊列們 = 資料.map((r, i) => ({
        key: 用id當key ? r.id : i,
        dom: 造節點(),
      }))
      舊列們[1].dom.value = '要訂便當'                      // 使用者在夏季展那行打字

      // 刪掉第一筆,重新配對。白話解釋:這張表就是 React 的 mapRemainingChildren
      const 對照表 = new Map(舊列們.map((r) => [r.key, r]))
      const 新資料 = 資料.slice(1)
      const 新列們 = 新資料.map((r, i) => {
        const 查詢用key = 用id當key ? r.id : i              // ← 整篇的關鍵就是這一行
        const 撈到的 = 對照表.get(查詢用key)
        // 白話解釋:撈到舊的就沿用它的 DOM 節點,撈不到才造新的
        return { 名稱: r.名稱, dom: 撈到的 ? 撈到的.dom : 造節點() }
      })

      const 有字的那列 = 新列們.find((r) => r.dom.value === '要訂便當')
      return 有字的那列 ? 有字的那列.名稱 : '字不見了'
    }

    return {
      斷言: [
        ['用 index 當 key,那串字跑到了', 跑(false), '秋季展'],
        ['用 id 當 key,那串字還在', 跑(true), '夏季展'],
      ],
      附註: '兩種 key 新建的 DOM 數量都是 0 —— 問題不是效能,是配給了錯的資料',
    }
  },
)

// ============================================================
// 線四:遠端資料 —— 「參考穩了,畫面才有機會不動」
// ============================================================

檢查(
  'Day 26|快取回來後要不要重繪',
  'structural sharing 內容沒變就回傳原本那個物件,但一個 Date 欄位就整包破功',
  () => {
    /** 照 @tanstack/query-core 的 replaceEqualDeep 寫的最小版 */
    function replaceEqualDeep(a, b) {
      if (a === b) return a

      const 是單純陣列 = Array.isArray(a) && Array.isArray(b)
      const 是單純物件 = (v) => Object.prototype.toString.call(v) === '[object Object]'
      // 白話解釋:這一行是守門員 —— 不是單純物件或單純陣列的東西,直接換成新的那份
      if (!是單純陣列 && !(是單純物件(a) && 是單純物件(b))) return b

      const aKeys = 是單純陣列 ? a : Object.keys(a)
      const bKeys = 是單純陣列 ? b : Object.keys(b)
      const copy = 是單純陣列 ? new Array(bKeys.length) : {}
      let 相等的欄位數 = 0

      for (let i = 0; i < bKeys.length; i += 1) {
        const key = 是單純陣列 ? i : bKeys[i]
        const v = replaceEqualDeep(a[key], b[key])
        copy[key] = v
        if (v === a[key]) 相等的欄位數 += 1
      }
      // 白話解釋:欄位數一樣,而且每一欄都判定相等 → 回傳舊的那個 a,新的那份丟掉
      return aKeys.length === bKeys.length && 相等的欄位數 === aKeys.length ? a : copy
    }

    const 舊 = { sold: 120, list: [{ id: 1 }], meta: { page: 1 } }
    const 內容相同的新 = JSON.parse(JSON.stringify(舊))
    const 只改了sold = { ...內容相同的新, sold: 45 }
    const 改完的 = replaceEqualDeep(舊, 只改了sold)

    const 含Date舊 = { sold: 1, at: new Date('2026-01-01') }
    const 含Date新 = { sold: 1, at: new Date('2026-01-01') }

    return {
      斷言: [
        ['純 JSON 內容相同 → 回傳原本那個物件', replaceEqualDeep(舊, 內容相同的新) === 舊, true],
        ['只改一欄 → 外層換新', 改完的 === 舊, false],
        ['只改一欄 → 沒改的 list 仍然共用', 改完的.list === 舊.list, true],
        ['只改一欄 → 沒改的 meta 仍然共用', 改完的.meta === 舊.meta, true],
        ['有一個 Date 欄位 → 整包都不重用', replaceEqualDeep(含Date舊, 含Date新) === 含Date舊, false],
      ],
      附註: '所以在 select 裡把字串轉成 Date,會把整包資料的 structural sharing 關掉',
    }
  },
)

檢查(
  'Day 21|樂觀更新',
  '樂觀更新買到的是「跨過 100 毫秒那條線」,不是一個倍數',
  () => {
    const 伺服器延遲 = 120            // 這裡刻意縮短,讓這支檢查跑得快
    const t0 = process.hrtime.bigint()
    const 現在 = () => Number(process.hrtime.bigint() - t0) / 1e6

    // 樂觀:在等伺服器之前就先畫
    const 樂觀 = 現在()

    // 白話解釋:用忙等模擬「等伺服器」,這樣整個檢查可以是同步的
    const 截止 = Date.now() + 伺服器延遲
    while (Date.now() < 截止) { /* 等 */ }

    // 悲觀:等完才畫
    const 悲觀 = 現在()

    return {
      斷言: [
        ['樂觀落在 Nielsen 的第一區間(100ms 以內)', 樂觀 < 100, true],
        ['悲觀落在第二區間(超過 100ms)', 悲觀 > 100, true],
      ],
      附註: `實測 ${樂觀.toFixed(4)}ms 對 ${悲觀.toFixed(1)}ms。`
        + '倍數算得出來但沒有意義 —— 分母是同步執行一行程式碼',
    }
  },
)

檢查(
  'Day 21|樂觀更新',
  '兩個 mutation 同時在飛,整份快照回滾會把別人成功的結果一起抹掉',
  () => {
    let 快取 = { status: 'draft', title: '場地規範' }
    let 資料庫 = { status: 'draft', title: '場地規範' }

    // A:按下發布。先備份整份快取 —— 這就是 context.previous
    const 快照A = 快取
    快取 = { ...快取, status: 'published' }

    // B:改標題。白話解釋:B 的快照裡已經含有 A 還沒確定的樂觀值
    const 快照B = 快取
    快取 = { ...快取, title: '場地規範 v2' }

    // B 成功,真的寫進資料庫,然後 onSuccess 用伺服器回應蓋掉整份快取
    資料庫 = { ...資料庫, title: '場地規範 v2' }
    快取 = { ...資料庫 }

    // A 失敗,把 A 的快照寫回去
    快取 = 快照A

    return {
      斷言: [
        ['A 的快照裡沒有 B 的成功結果', 快照A.title, '場地規範'],
        ['B 真的寫進資料庫了', 資料庫.title, '場地規範 v2'],
        ['但畫面被 A 的回滾打回去了', 快取.title, '場地規範'],
        ['畫面與資料庫一致', 快取.title === 資料庫.title, false],
      ],
      附註: '回滾寫回的是「那個 mutation 開始時的整個世界」,不是「它改的那一欄」'
        + `(B 的快照是 ${快照B.title},裡面含有 A 的樂觀值 ${快照B.status})`,
    }
  },
)

// ============================================================
// 線五:上線 —— 「建置時與執行時是兩個時間點」
// ============================================================

檢查(
  'Day 29|本機正常上線就炸',
  '|| 預設值把「忘了設環境變數」翻譯成「安靜地連去別的地方」',
  () => {
    const 正式環境忘了設 = { DB_PASSWORD: 'prodpass', DB_HOST: 'db.internal' }
    const 正式環境設成空字串 = { DB_USER: '', DB_PASSWORD: 'prodpass', DB_HOST: 'db.internal' }

    /** 白話解釋:掃一遍必要清單,undefined 與空字串都算缺,缺了就不讓服務起來 */
    const 啟動前檢查 = (env) => {
      const 缺的 = ['DB_USER', 'DB_PASSWORD', 'DB_HOST']
        .filter((k) => env[k] === undefined || env[k] === '')
      return 缺的.length === 0 ? '通過' : '擋下來'
    }

    return {
      斷言: [
        ['忘了設:|| 會連去', 正式環境忘了設.DB_USER || 'postgres', 'postgres'],
        ['忘了設:?? 也會連去', 正式環境忘了設.DB_USER ?? 'postgres', 'postgres'],
        ['設成空字串:|| 會連去', 正式環境設成空字串.DB_USER || 'postgres', 'postgres'],
        ['設成空字串:?? 回傳空字串', 正式環境設成空字串.DB_USER ?? 'postgres', ''],
        ['忘了設:啟動前檢查', 啟動前檢查(正式環境忘了設), '擋下來'],
        ['設成空字串:啟動前檢查', 啟動前檢查(正式環境設成空字串), '擋下來'],
      ],
      附註: '所以解法不是把 || 換成 ??,是連線設定根本不該有預設值',
    }
  },
)

檢查(
  'Day 29|本機正常上線就炸',
  '沒有引號的時候,# 之後會被當成註解砍掉,密碼會被截斷成一半',
  () => {
    /** 照 dotenv 的語意寫的最小版 parser */
    function 解析(內容) {
      const out = {}
      for (const 原行 of 內容.split('\n')) {
        const 行 = 原行.trim()
        if (行 === '' || 行.startsWith('#')) continue
        const i = 行.indexOf('=')
        if (i < 0) continue
        let v = 行.slice(i + 1).trim()
        const 成對雙引號 = v.startsWith('"') && v.endsWith('"') && v.length >= 2
        const 成對單引號 = v.startsWith("'") && v.endsWith("'") && v.length >= 2
        if (成對雙引號 || 成對單引號) {
          v = v.slice(1, -1)                         // 有引號 → 去掉引號,# 保留
        } else {
          const j = v.indexOf('#')
          if (j >= 0) v = v.slice(0, j).trim()       // 沒引號 → # 之後當註解砍掉
        }
        out[行.slice(0, i).trim()] = v
      }
      return out
    }

    const 真密碼 = 'p@ss#w0rd!'
    const r = 解析(`PLAIN=${真密碼}\nQUOTED="${真密碼}"`)

    return {
      斷言: [
        ['沒引號解出來的值', r.PLAIN, 'p@ss'],
        ['沒引號少了幾個字元', 真密碼.length - r.PLAIN.length, 6],
        ['有引號解出來的值', r.QUOTED, 真密碼],
      ],
      附註: '錯誤訊息會是 password authentication failed,但密碼其實是對的',
    }
  },
)

檢查(
  'Day 1、2|命令式與宣告式',
  'every() 找到第一個 false 就停,所以它不是「全部跑完再判斷」',
  () => {
    const 資料 = [1, 2, 3, 4, 5]

    let every次數 = 0
    // 白話解釋:到 3 的時候 3 < 3 是 false,every 立刻回 false 並停止,不再往後跑
    資料.every((n) => { every次數 += 1; return n < 3 })

    let some次數 = 0
    // 白話解釋:some 相反 —— 找到第一個 true 就停
    資料.some((n) => { some次數 += 1; return n >= 3 })

    let forEach次數 = 0
    資料.forEach(() => { forEach次數 += 1 })

    return {
      斷言: [
        ['every 呼叫次數(第 3 個回 false 就停)', every次數, 3],
        ['some 呼叫次數(第 3 個回 true 就停)', some次數, 3],
        ['forEach 呼叫次數(不會停)', forEach次數, 5],
      ],
      附註: '所以那個 for + break + flag 的版本,換成 every() 不是只有變短,行為也一樣會短路',
    }
  },
)

// ============================================================
// 跑完全部並印表
// ============================================================

function main() {
  console.log('day30-series-self-check.js')
  console.log(`Node ${process.version}|執行時間 ${new Date().toISOString()}`)
  console.log('把這 30 天的頭條結論各自重跑一次。沒有任何依賴。')

  分隔線(`開始跑 ${檢查們.length} 個檢查`)

  const 結果們 = []
  let 斷言總數 = 0
  let 斷言通過數 = 0

  for (const c of 檢查們) {
    let 斷言 = []
    let 附註 = ''
    let 錯誤 = null
    try {
      const r = c.跑()
      斷言 = r.斷言
      附註 = r.附註 || ''
    } catch (e) {
      錯誤 = e
    }

    const 逐項 = 斷言.map(([標籤, 實際, 期望]) => ({
      標籤, 實際, 期望, 通過: Object.is(實際, 期望),
    }))
    const 通過 = 錯誤 === null && 逐項.length > 0 && 逐項.every((x) => x.通過)

    斷言總數 += 逐項.length
    斷言通過數 += 逐項.filter((x) => x.通過).length
    結果們.push({ ...c, 逐項, 附註, 通過, 錯誤 })

    console.log(`\n[${通過 ? '通過' : 錯誤 ? '執行失敗' : '不符預期'}] ${c.哪一天}`)
    console.log(`  結論:${c.結論}`)
    if (錯誤) {
      console.log(`  執行時丟出:${錯誤.constructor.name}:${錯誤.message}`)
    } else {
      for (const x of 逐項) {
        const 記號 = x.通過 ? ' ' : '✗'
        const 差異 = x.通過 ? '' : `   ← 期望 ${印值(x.期望)}`
        console.log(`   ${記號} ${x.標籤}:${印值(x.實際)}${差異}`)
      }
      if (附註) console.log(`  附註:${附註}`)
    }
  }

  const 通過的檢查數 = 結果們.filter((r) => r.通過).length

  分隔線('總表')
  console.log('')
  console.log('| 對應的那一篇 | 頭條結論 | 斷言 | 結果 |')
  console.log('|---|---|---|---|')
  for (const r of 結果們) {
    const 通過數 = r.逐項.filter((x) => x.通過).length
    console.log(`| ${r.哪一天} | ${r.結論} | ${通過數}/${r.逐項.length} | ${r.通過 ? '通過' : '**不符預期**'} |`)
  }
  console.log('')
  console.log(`  檢查:${通過的檢查數} / ${結果們.length} 通過`)
  console.log(`  斷言:${斷言通過數} / ${斷言總數} 通過`)

  console.log('')
  if (通過的檢查數 === 結果們.length) {
    console.log('  全部通過。這代表這 30 天寫出來的每一條頭條結論,在你這台機器上也成立。')
    console.log('')
    console.log('  這支檔案真正的用途不是今天跑一次就好,是**哪一天它開始不通過**:')
    console.log('    那表示 Node、React 或 TanStack Query 改了行為,')
    console.log('    而那一刻你會知道是哪一條結論過期了,不用重讀 30 篇文章。')
  } else {
    console.log('  有檢查不通過。不通過本身就是結論 —— 上面標 ✗ 的那一行就是差異所在。')
  }

  process.exitCode = 通過的檢查數 === 結果們.length ? 0 : 1
}

main()

這篇的每個說法各自從哪來

一、官方文件與可查證的一手來源

這篇是收束,所以外部出處大多是前面各篇已經列過的。這裡只列第三節那三個結論直接依賴的:

內容 出處
三個反應時間界線(0.1 秒/1 秒/10 秒) Jakob Nielsen, Response Times: The 3 Important Limits, Nielsen Norman Group,1993-01-01:https://www.nngroup.com/articles/response-times-3-important-limits/
Object.is 的比較語意 MDN:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
setState 的 12 行、hasOwnProperty.call、createWorkInProgress 的 workInProgress.stateNode = current.stateNode React v19.3.0 原始碼 ReactBaseClasses.js 與 ReactFiber.js:https://github.com/facebook/react/blob/v19.3.0/packages/react/src/ReactBaseClasses.js / https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiber.js
mapRemainingChildren 的「Implicit (null) keys get added to this set with their index instead」 React v19.3.0 ReactChildFiber.js:https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactChildFiber.js
structural sharing「內容沒變就保留原參考」 TanStack Query 官方文件 Render Optimizations:https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations
docker run --env-file 會保留引號 moby/moby issue #46773:https://github.com/moby/moby/issues/46773
useMemo 的依賴用 Object.is 比較、「只把它當成效能最佳化」 React 官方文件:https://react.dev/reference/react/useMemo

二、我實際跑出來的部分

Day 30 的 11 個檢查、42 個斷言,由 day30-series-self-check.js 實測產生(Node.js v22.22.2,2026-10-01 執行),全部通過,結束碼 0。可以重跑驗證。

第三節引用的 602ms、0.01ms、「新建 DOM 都是 0」、「八行裡六行不同」這些數字,分別來自 Day 21、Day 27、Day 29 的 demo,不是這一篇新量的。

這支 self-check 裡的 replaceEqualDeep 與 dotenv parser 都是我照原始碼與文件寫的最小版,不是真的函式庫。 原因是我要讓它零依賴 —— 三年後還跑得起來比「用真的套件」更重要。要驗證真實函式庫的行為,請用 Day 26 與 Day 29 那兩支(它們會去 require 真的套件)。

三、我自己的整理與判斷(沒有外部出處)

  • 把 30 篇重排成五條線,以及每條線收在一個問句上 —— 這個切法完全是我整理的,不是任何既有的分類法
  • 「React 沒有攔截層所以只能比參考」當成整個系列的根 —— 這個因果關係成立,但「把它當成唯一的根」是我的敘事選擇,不是唯一的讀法
  • 第二節那個四段式的 20 分鐘順序,以及「挑一條線走完比每條講三句好」這個建議
  • 第四節那六組對照組的配對方式
  • 第六節三個建議,特別是「抽掉中間任何一天,後面還讀得通,那條線就不夠緊」這個判準
  • 「那五個問句才是真正的產出,因為答案會過期、問法不會」這句總結

四、我沒有驗證的部分

  • 第一節表格裡 Day 1 到 Day 9 的日期對應,我是照最初的 30 天大綱寫的,不是照實際發文順序。 這個系列的發文順序中途調整過好幾次(最明顯的是「靜態方法與實例方法」那篇,大綱排在 Day 10,實際發在 Day 3),所以早期那幾天的日期可能跟你看到的不完全一致。Day 16 之後的日期我是直接從檔案對過的,那部分準
  • 第二節那個「20 分鐘四段」我沒有在真實面試裡試過。 它是我根據「對方要的是結構」這個判斷排出來的,不是實戰驗證過的腳本。時間分配(2/6/8/4)更是估的
  • 第六節的三個建議只來自我這一個系列的經驗,樣本數是 1。 特別是「零依賴優先」這一條,如果你的主題本身就是某個函式庫的行為,那建議可能不適用
  • 「它會擋住你寫出漂亮但錯的句子」 這個說法我沒辦法量化。它是我寫這 30 天的主觀感受
  • self-check 的斷言覆蓋率我沒有計算。 11 個檢查不等於涵蓋了 30 篇的全部結論 —— 它只挑了可以用純 Node 同步驗證的那些。需要瀏覽器、需要真實 React render、需要 docker daemon 的結論(例如 Day 27 的雙緩衝實測、Day 29 的 --env-file)都沒有進來

五、幾個要講清楚的限制

  • 這支 self-check 驗證的是「結論在我的最小模型裡成立」,不是「真實函式庫的行為」。 兩者在前面各篇是分開量過的,但這一篇為了零依賴而統一用最小模型。這是一個取捨,不是疏漏,但你要知道它是取捨
  • Day 21 那個檢查我把伺服器延遲從 600ms 縮到 120ms,純粹是為了讓整支 self-check 跑得快。它驗證的是「落在哪個區間」這個性質,不是重現文章裡那個數字
  • Day 21 那個檢查用忙等(busy wait)模擬網路延遲,所以它會真的吃 CPU 120 毫秒。這在 self-check 裡可以接受,但不要把那段寫法抄到任何真實程式碼裡
  • 第三節引用的所有數字都來自雲端容器,CPU 是共享的。看的是數量級與方向,不要記死數字

(查閱日期:2026-10-01。程式碼實測於 Node.js v22.22.2)


相關筆記與關聯原因

  • 00-30天大綱-從JS核心機制到React核心原理.md:關聯原因:那是出發前的規劃,這篇是走完之後的地圖。兩份放在一起看會發現實際走的路跟規劃差很多 —— 而差在哪本身就是有用的資訊(規劃時把「讀原始碼」散在各處,實際寫起來它自己長成了一整條線)
  • Day 23|攔截、收集、觸發:關聯原因:這篇講的「共同的根」就是那一篇的結論。如果只讀一篇,讀那一篇
  • Day 24|setState 只有 12 行 與 Day 27|key 是身分證:關聯原因:線二的兩個代表作,而且它們示範的是同一套方法 —— 讀原始碼那幾行、手寫最小版驗證。想學這個方法,看這兩篇的 demo 怎麼寫的
  • areas/technical-interview-prep 那組筆記:關聯原因:第二節那個 20 分鐘順序應該併進去,因為它才是這個系列對面試最直接有用的產出
  • frontend-docs/ 整包:關聯原因:這 30 天有大量內容同時寫回了那些主題筆記(最多的是 react/、tanstack/、web-platform/)。文章是線性的,筆記是網狀的 —— 要查某個單點,去筆記;要理解一條線,回來看文章

最後

30 天結束了,留下來的其實是三樣東西:

一、一個問法,不是一堆答案。

五條線各收在一個問句上,而那五個問句可以直接套到沒讀過的題目上。答案會過期 —— React 會改版、預設值會變 —— 但問句不會。

二、29 支可以重跑的 demo。

每一篇的結論都有一支對應的 .js,絕大多數零依賴。所以這些不是「我覺得」,是「你可以在你的機器上跑一次」。

三、一支會過期警報的自我檢查。

node day30-series-self-check.js。它今天 42/42 通過,而它真正的價值在哪一天它開始不通過 —— 那一刻你會知道是哪一條結論過期了。
/**

  • day30-series-self-check.js
  • 把這 30 天的頭條結論,一個指令全部重跑一次
  • 搭配 iThome 鐵人賽 2026 Day 30
  • 執行方式:node day30-series-self-check.js
  • 環境:Node.js v18 以上。沒有任何依賴
  • 這支檔案跟前面 29 天的 demo 不一樣:
  • 它不解釋機制(那是前面每一篇的事),它只做一件事 ——
  • 把每一篇的頭條結論寫成一個會自己判斷對錯的斷言,然後印出通過或失敗。
  • 為什麼要有這支檔案:
  • 整個系列最怕的不是寫錯,是「寫得很有道理但沒人驗證得了」。
  • 所以收尾留下的不是心得,是一支可以重跑的檢查。
  • 它真正的用途是「哪一天它開始不通過」—— 那表示某一條結論過期了,
  • 而你會知道是哪一條,不用重讀 30 篇文章。
    */

'use strict'

// ============================================================
// 檢查框架
// ============================================================

const 檢查們 = []

/**

  • 註冊一個檢查
  • @param {string} 哪一天 對應的那一篇
  • @param {string} 結論 那一篇的頭條結論,用一句話寫
  • @param {Function} 跑 回傳 { 斷言: [[標籤, 實際值, 期望值], ...], 附註?: string }
    */
    function 檢查(哪一天, 結論, 跑) {
    檢查們.push({ 哪一天, 結論, 跑 })
    }

function 分隔線(title) {
console.log('\n' + '='.repeat(74))
console.log(title)
console.log('='.repeat(74))
}

/** 把值印成人看得懂的形式 */
const 印值 = (v) => {
if (v === '') return "''(空字串)"
return typeof v === 'string' ? v : JSON.stringify(v)
}

// ============================================================
// 線一:值與記憶體 —— 「React 只能比參考」
// ============================================================

檢查(
'Day 4|相等性四種演算法',
'Object.is 比的是參考,所以「原地改物件」在 React 眼裡等於沒變',
() => {
const 原本 = { sold: 120 }

// 白話解釋:原地改是同一個物件,所以參考不變
const 原地改 = 原本
原地改.sold = 45

// 白話解釋:展開運算子會產生一個新物件,所以參考一定不同
const 給新物件 = { ...原本, sold: 45 }

// 白話解釋:淺複製只換外層,但外層換了參考就已經不同了
const 淺複製 = { ...原本 }

return {
  斷言: [
    ['原地改之後還是同一個參考', Object.is(原本, 原地改), true],
    ['給新物件是不同參考', Object.is(原本, 給新物件), false],
    ['淺複製是不同參考', Object.is(原本, 淺複製), false],
  ],
  附註: '所以 React 要你給新物件不是風格要求,是它只有這一種判斷方式',
}

},
)

檢查(
'Day 9|物件的真面目',
'in 看整條原型鏈、hasOwnProperty 只看自己、Object.keys 還多要求可列舉',
() => {
const 父 = { 繼承來的: 1 }
const 子 = Object.create(父)
子.自己的 = 2
// 白話解釋:用 defineProperty 定義而不給 enumerable,預設就是不可列舉
Object.defineProperty(子, '不可列舉的', { value: 3, enumerable: false })

const has = Object.prototype.hasOwnProperty

return {
  斷言: [
    ['繼承來的:in', '繼承來的' in 子, true],
    ['繼承來的:hasOwnProperty', has.call(子, '繼承來的'), false],
    ['繼承來的:Object.keys', Object.keys(子).includes('繼承來的'), false],
    ['不可列舉的:in', '不可列舉的' in 子, true],
    ['不可列舉的:hasOwnProperty', has.call(子, '不可列舉的'), true],
    ['不可列舉的:Object.keys', Object.keys(子).includes('不可列舉的'), false],
  ],
  附註: '三個分界:要不要走原型鏈、可不可列舉、存不存在',
}

},
)

// ============================================================
// 線二:讀原始碼 —— 「借方法來問,不要問物件自己」
// ============================================================

檢查(
'Day 24|setState 只有 12 行',
'config 是外部資料,所以 obj.hasOwnProperty(k) 會被地雷炸掉,React 才寫 .call',
() => {
const has = Object.prototype.hasOwnProperty

// 白話解釋:天真版問物件自己有沒有這個 key,前提是那個物件身上真的有這個方法
const 天真 = (config) => {
  const out = {}
  for (const k in config) if (config.hasOwnProperty(k)) out[k] = config[k]
  return out
}
// 白話解釋:React 版從 Object.prototype 把方法借出來,用 .call 指定「要問誰」
const 借用 = (config) => {
  const out = {}
  for (const k in config) if (has.call(config, k)) out[k] = config[k]
  return out
}

const 地雷一 = { hasOwnProperty: 'oops', title: 'hi' }      // 名字被 prop 佔走了
const 地雷二 = Object.create(null); 地雷二.title = 'hi'       // 根本沒有原型可以繼承

/** 跑一次,回傳 'ok' 或錯誤的型別名稱 */
const 試 = (fn, config) => {
  try { fn(config); return 'ok' } catch (e) { return e.constructor.name }
}

return {
  斷言: [
    ['天真版遇到 prop 名叫 hasOwnProperty', 試(天真, 地雷一), 'TypeError'],
    ['天真版遇到 Object.create(null)', 試(天真, 地雷二), 'TypeError'],
    ['借用版遇到 prop 名叫 hasOwnProperty', 試(借用, 地雷一), 'ok'],
    ['借用版遇到 Object.create(null)', 試(借用, 地雷二), 'ok'],
  ],
  附註: '規則不是「一律加 .call」,是「物件在不在自己控制範圍內」',
}

},
)

檢查(
'Day 24|setState 只有 12 行',
'setState 自己不改 state、不重繪、不排程,它只把事情丟給 this.updater',
() => {
// 照抄 React v19.3.0 ReactBaseClasses.js 的那 12 行(註解省略)
function setState照抄版(partialState, callback) {
if (
typeof partialState !== 'object' &&
typeof partialState !== 'function' &&
partialState != null
) {
throw new Error('takes an object of state variables to update or a '
+ 'function which returns an object of state variables.')
}
this.updater.enqueueSetState(this, partialState, callback, 'setState')
}

const 收到的 = []
const 元件 = {
  state: { count: 0 },
  // 白話解釋:這個假的 updater 只負責把收到的 patch 記下來,什麼事都不做
  updater: { enqueueSetState(inst, patch) { 收到的.push(patch) } },
  setState: setState照抄版,
}

元件.setState({ count: 1 })

const 丟錯 = (參數) => {
  try { 元件.setState(參數); return false } catch (e) { return true }
}

return {
  斷言: [
    ['呼叫之後 this.state 完全沒變', 元件.state.count, 0],
    ['updater 收到了那筆 patch', 收到的.length, 1],
    ['setState(123) 會丟錯', 丟錯(123), true],
    ['setState(null) 不會丟錯', 丟錯(null), false],
  ],
  附註: 'null 被放過是因為條件寫的是 partialState != null(寬鬆比較)',
}

},
)

// ============================================================
// 線三:列表與身分 —— 「key 是身分證,不是效能設定」
// ============================================================

檢查(
'Day 27|key 不是效能設定是身分證',
'不給 key 等於 key 用 index,刪掉第一筆會讓使用者打的字留在錯的那一行',
() => {
/** 假的 DOM 節點。value 模擬使用者打進去的字 —— 這個值 React 不知道它的存在 */
const 造節點 = () => ({ value: '' })

/**
 * 跑一次「使用者在第二列打字 → 刪掉第一筆」
 * 回傳「那串字最後出現在哪一列」
 */
function 跑(用id當key) {
  const 資料 = [
    { id: 'a', 名稱: '春季展' }, { id: 'b', 名稱: '夏季展' },
    { id: 'c', 名稱: '秋季展' }, { id: 'd', 名稱: '冬季展' },
  ]
  // 首次掛載:每一列配一個 DOM 節點
  const 舊列們 = 資料.map((r, i) => ({
    key: 用id當key ? r.id : i,
    dom: 造節點(),
  }))
  舊列們[1].dom.value = '要訂便當'                      // 使用者在夏季展那行打字

  // 刪掉第一筆,重新配對。白話解釋:這張表就是 React 的 mapRemainingChildren
  const 對照表 = new Map(舊列們.map((r) => [r.key, r]))
  const 新資料 = 資料.slice(1)
  const 新列們 = 新資料.map((r, i) => {
    const 查詢用key = 用id當key ? r.id : i              // ← 整篇的關鍵就是這一行
    const 撈到的 = 對照表.get(查詢用key)
    // 白話解釋:撈到舊的就沿用它的 DOM 節點,撈不到才造新的
    return { 名稱: r.名稱, dom: 撈到的 ? 撈到的.dom : 造節點() }
  })

  const 有字的那列 = 新列們.find((r) => r.dom.value === '要訂便當')
  return 有字的那列 ? 有字的那列.名稱 : '字不見了'
}

return {
  斷言: [
    ['用 index 當 key,那串字跑到了', 跑(false), '秋季展'],
    ['用 id 當 key,那串字還在', 跑(true), '夏季展'],
  ],
  附註: '兩種 key 新建的 DOM 數量都是 0 —— 問題不是效能,是配給了錯的資料',
}

},
)

// ============================================================
// 線四:遠端資料 —— 「參考穩了,畫面才有機會不動」
// ============================================================

檢查(
'Day 26|快取回來後要不要重繪',
'structural sharing 內容沒變就回傳原本那個物件,但一個 Date 欄位就整包破功',
() => {
/** 照 @tanstack/query-core 的 replaceEqualDeep 寫的最小版 */
function replaceEqualDeep(a, b) {
if (a === b) return a

  const 是單純陣列 = Array.isArray(a) && Array.isArray(b)
  const 是單純物件 = (v) => Object.prototype.toString.call(v) === '[object Object]'
  // 白話解釋:這一行是守門員 —— 不是單純物件或單純陣列的東西,直接換成新的那份
  if (!是單純陣列 && !(是單純物件(a) && 是單純物件(b))) return b

  const aKeys = 是單純陣列 ? a : Object.keys(a)
  const bKeys = 是單純陣列 ? b : Object.keys(b)
  const copy = 是單純陣列 ? new Array(bKeys.length) : {}
  let 相等的欄位數 = 0

  for (let i = 0; i < bKeys.length; i += 1) {
    const key = 是單純陣列 ? i : bKeys[i]
    const v = replaceEqualDeep(a[key], b[key])
    copy[key] = v
    if (v === a[key]) 相等的欄位數 += 1
  }
  // 白話解釋:欄位數一樣,而且每一欄都判定相等 → 回傳舊的那個 a,新的那份丟掉
  return aKeys.length === bKeys.length && 相等的欄位數 === aKeys.length ? a : copy
}

const 舊 = { sold: 120, list: [{ id: 1 }], meta: { page: 1 } }
const 內容相同的新 = JSON.parse(JSON.stringify(舊))
const 只改了sold = { ...內容相同的新, sold: 45 }
const 改完的 = replaceEqualDeep(舊, 只改了sold)

const 含Date舊 = { sold: 1, at: new Date('2026-01-01') }
const 含Date新 = { sold: 1, at: new Date('2026-01-01') }

return {
  斷言: [
    ['純 JSON 內容相同 → 回傳原本那個物件', replaceEqualDeep(舊, 內容相同的新) === 舊, true],
    ['只改一欄 → 外層換新', 改完的 === 舊, false],
    ['只改一欄 → 沒改的 list 仍然共用', 改完的.list === 舊.list, true],
    ['只改一欄 → 沒改的 meta 仍然共用', 改完的.meta === 舊.meta, true],
    ['有一個 Date 欄位 → 整包都不重用', replaceEqualDeep(含Date舊, 含Date新) === 含Date舊, false],
  ],
  附註: '所以在 select 裡把字串轉成 Date,會把整包資料的 structural sharing 關掉',
}

},
)

檢查(
'Day 21|樂觀更新',
'樂觀更新買到的是「跨過 100 毫秒那條線」,不是一個倍數',
() => {
const 伺服器延遲 = 120 // 這裡刻意縮短,讓這支檢查跑得快
const t0 = process.hrtime.bigint()
const 現在 = () => Number(process.hrtime.bigint() - t0) / 1e6

// 樂觀:在等伺服器之前就先畫
const 樂觀 = 現在()

// 白話解釋:用忙等模擬「等伺服器」,這樣整個檢查可以是同步的
const 截止 = Date.now() + 伺服器延遲
while (Date.now() < 截止) { /* 等 */ }

// 悲觀:等完才畫
const 悲觀 = 現在()

return {
  斷言: [
    ['樂觀落在 Nielsen 的第一區間(100ms 以內)', 樂觀 < 100, true],
    ['悲觀落在第二區間(超過 100ms)', 悲觀 > 100, true],
  ],
  附註: `實測 ${樂觀.toFixed(4)}ms 對 ${悲觀.toFixed(1)}ms。`
    + '倍數算得出來但沒有意義 —— 分母是同步執行一行程式碼',
}

},
)

檢查(
'Day 21|樂觀更新',
'兩個 mutation 同時在飛,整份快照回滾會把別人成功的結果一起抹掉',
() => {
let 快取 = { status: 'draft', title: '場地規範' }
let 資料庫 = { status: 'draft', title: '場地規範' }

// A:按下發布。先備份整份快取 —— 這就是 context.previous
const 快照A = 快取
快取 = { ...快取, status: 'published' }

// B:改標題。白話解釋:B 的快照裡已經含有 A 還沒確定的樂觀值
const 快照B = 快取
快取 = { ...快取, title: '場地規範 v2' }

// B 成功,真的寫進資料庫,然後 onSuccess 用伺服器回應蓋掉整份快取
資料庫 = { ...資料庫, title: '場地規範 v2' }
快取 = { ...資料庫 }

// A 失敗,把 A 的快照寫回去
快取 = 快照A

return {
  斷言: [
    ['A 的快照裡沒有 B 的成功結果', 快照A.title, '場地規範'],
    ['B 真的寫進資料庫了', 資料庫.title, '場地規範 v2'],
    ['但畫面被 A 的回滾打回去了', 快取.title, '場地規範'],
    ['畫面與資料庫一致', 快取.title === 資料庫.title, false],
  ],
  附註: '回滾寫回的是「那個 mutation 開始時的整個世界」,不是「它改的那一欄」'
    + `(B 的快照是 ${快照B.title},裡面含有 A 的樂觀值 ${快照B.status})`,
}

},
)

// ============================================================
// 線五:上線 —— 「建置時與執行時是兩個時間點」
// ============================================================

檢查(
'Day 29|本機正常上線就炸',
'|| 預設值把「忘了設環境變數」翻譯成「安靜地連去別的地方」',
() => {
const 正式環境忘了設 = { DB_PASSWORD: 'prodpass', DB_HOST: 'db.internal' }
const 正式環境設成空字串 = { DB_USER: '', DB_PASSWORD: 'prodpass', DB_HOST: 'db.internal' }

/** 白話解釋:掃一遍必要清單,undefined 與空字串都算缺,缺了就不讓服務起來 */
const 啟動前檢查 = (env) => {
  const 缺的 = ['DB_USER', 'DB_PASSWORD', 'DB_HOST']
    .filter((k) => env[k] === undefined || env[k] === '')
  return 缺的.length === 0 ? '通過' : '擋下來'
}

return {
  斷言: [
    ['忘了設:|| 會連去', 正式環境忘了設.DB_USER || 'postgres', 'postgres'],
    ['忘了設:?? 也會連去', 正式環境忘了設.DB_USER ?? 'postgres', 'postgres'],
    ['設成空字串:|| 會連去', 正式環境設成空字串.DB_USER || 'postgres', 'postgres'],
    ['設成空字串:?? 回傳空字串', 正式環境設成空字串.DB_USER ?? 'postgres', ''],
    ['忘了設:啟動前檢查', 啟動前檢查(正式環境忘了設), '擋下來'],
    ['設成空字串:啟動前檢查', 啟動前檢查(正式環境設成空字串), '擋下來'],
  ],
  附註: '所以解法不是把 || 換成 ??,是連線設定根本不該有預設值',
}

},
)

檢查(
'Day 29|本機正常上線就炸',
'沒有引號的時候,# 之後會被當成註解砍掉,密碼會被截斷成一半',
() => {
/** 照 dotenv 的語意寫的最小版 parser */
function 解析(內容) {
const out = {}
for (const 原行 of 內容.split('\n')) {
const 行 = 原行.trim()
if (行 === '' || 行.startsWith('#')) continue
const i = 行.indexOf('=')
if (i < 0) continue
let v = 行.slice(i + 1).trim()
const 成對雙引號 = v.startsWith('"') && v.endsWith('"') && v.length >= 2
const 成對單引號 = v.startsWith("'") && v.endsWith("'") && v.length >= 2
if (成對雙引號 || 成對單引號) {
v = v.slice(1, -1) // 有引號 → 去掉引號,# 保留
} else {
const j = v.indexOf('#')
if (j >= 0) v = v.slice(0, j).trim() // 沒引號 → # 之後當註解砍掉
}
out[行.slice(0, i).trim()] = v
}
return out
}

const 真密碼 = 'p@ss#w0rd!'
const r = 解析(`PLAIN=${真密碼}\nQUOTED="${真密碼}"`)

return {
  斷言: [
    ['沒引號解出來的值', r.PLAIN, 'p@ss'],
    ['沒引號少了幾個字元', 真密碼.length - r.PLAIN.length, 6],
    ['有引號解出來的值', r.QUOTED, 真密碼],
  ],
  附註: '錯誤訊息會是 password authentication failed,但密碼其實是對的',
}

},
)

檢查(
'Day 1、2|命令式與宣告式',
'every() 找到第一個 false 就停,所以它不是「全部跑完再判斷」',
() => {
const 資料 = [1, 2, 3, 4, 5]

let every次數 = 0
// 白話解釋:到 3 的時候 3 < 3 是 false,every 立刻回 false 並停止,不再往後跑
資料.every((n) => { every次數 += 1; return n < 3 })

let some次數 = 0
// 白話解釋:some 相反 —— 找到第一個 true 就停
資料.some((n) => { some次數 += 1; return n >= 3 })

let forEach次數 = 0
資料.forEach(() => { forEach次數 += 1 })

return {
  斷言: [
    ['every 呼叫次數(第 3 個回 false 就停)', every次數, 3],
    ['some 呼叫次數(第 3 個回 true 就停)', some次數, 3],
    ['forEach 呼叫次數(不會停)', forEach次數, 5],
  ],
  附註: '所以那個 for + break + flag 的版本,換成 every() 不是只有變短,行為也一樣會短路',
}

},
)

// ============================================================
// 跑完全部並印表
// ============================================================

function main() {
console.log('day30-series-self-check.js')
console.log(Node ${process.version}|執行時間 ${new Date().toISOString()})
console.log('把這 30 天的頭條結論各自重跑一次。沒有任何依賴。')

分隔線(開始跑 ${檢查們.length} 個檢查)

const 結果們 = []
let 斷言總數 = 0
let 斷言通過數 = 0

for (const c of 檢查們) {
let 斷言 = []
let 附註 = ''
let 錯誤 = null
try {
const r = c.跑()
斷言 = r.斷言
附註 = r.附註 || ''
} catch (e) {
錯誤 = e
}

const 逐項 = 斷言.map(([標籤, 實際, 期望]) => ({
  標籤, 實際, 期望, 通過: Object.is(實際, 期望),
}))
const 通過 = 錯誤 === null && 逐項.length > 0 && 逐項.every((x) => x.通過)

斷言總數 += 逐項.length
斷言通過數 += 逐項.filter((x) => x.通過).length
結果們.push({ ...c, 逐項, 附註, 通過, 錯誤 })

console.log(`\n[${通過 ? '通過' : 錯誤 ? '執行失敗' : '不符預期'}] ${c.哪一天}`)
console.log(`  結論:${c.結論}`)
if (錯誤) {
  console.log(`  執行時丟出:${錯誤.constructor.name}:${錯誤.message}`)
} else {
  for (const x of 逐項) {
    const 記號 = x.通過 ? ' ' : '✗'
    const 差異 = x.通過 ? '' : `   ← 期望 ${印值(x.期望)}`
    console.log(`   ${記號} ${x.標籤}:${印值(x.實際)}${差異}`)
  }
  if (附註) console.log(`  附註:${附註}`)
}

}

const 通過的檢查數 = 結果們.filter((r) => r.通過).length

分隔線('總表')
console.log('')
console.log('| 對應的那一篇 | 頭條結論 | 斷言 | 結果 |')
console.log('|---|---|---|---|')
for (const r of 結果們) {
const 通過數 = r.逐項.filter((x) => x.通過).length
console.log(| ${r.哪一天} | ${r.結論} | ${通過數}/${r.逐項.length} | ${r.通過 ? '通過' : '**不符預期**'} |)
}
console.log('')
console.log( 檢查:${通過的檢查數} / ${結果們.length} 通過)
console.log( 斷言:${斷言通過數} / ${斷言總數} 通過)

console.log('')
if (通過的檢查數 === 結果們.length) {
console.log(' 全部通過。這代表這 30 天寫出來的每一條頭條結論,在你這台機器上也成立。')
console.log('')
console.log(' 這支檔案真正的用途


上一篇
Day 29 | 本機正常、上線就炸 —— 建置時與執行時是兩個不同的時間點
系列文
現代函式庫與JavaScript的關係 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言